iT邦幫忙

1

🔄 從手動到自動:我如何讓年度事件在 Google 日曆中自動重複

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250624/20155103kcOURoFFxM.png

🧪 問題從「重複建立行事曆事件」開始

每年,我都會固定幾個月建立相似的 Google 行事曆事件。雖然可以手動新增並設定重複,但面對來自多來源、條件不一的事件類型時,這件事變得越來越耗時。

所以,我寫了一段 Google Apps Script,讓這些年度任務可以自動建立並設定為重複,不再每次都得複製貼上。


⚙️ 這個工具可以做到什麼?

這個自動化工具具備以下幾個能力:

  • 讀取 Google Sheet 中設定的年度與每月任務
  • 自動在 Google Calendar 中建立對應事件
  • 年度事件會設定為「每年平日重複」
  • 每月事件會在每月自動更新起訖日為 1 號至 5 號,並建立每日重複事件
  • 建立完成後發送 LINE 通知,並記錄在另一個工作表中

透過一份簡單的表格,我可以清楚管理未來的提醒,也能隨時編輯內容來同步到行事曆。


🎯 為什麼我需要這個工具?

我常常需要記得「提早查詢端午划龍舟日期」、「預約阿里山日出音樂會」這類每年固定要做的事情。它們的問題是:

  • 每年的日期都不一樣(例如端午節日期浮動)
  • 我想提前查,但不確定哪天有空
  • 若用「全天事件」或橫跨N天的事件,畫面會塞滿,影響可視性

所以,我偏好建立一個「4/1~5/30 的每週一到五早上 08:00–09:00」的提醒段落。這樣我每天打開行事曆就能看到這件事、卻不會太佔版面。


🛠️ 本來無法順利創建每月的重複事件

一開始我想要在每月的 1~5 號收到提醒,但 Google Calendar 並不支援「每月前五天」這樣的重複事件排程(特別是限定平日)。

後來我改變作法,使用 Google Apps Script 修改試算表的開始日與結束日為當月的1號到5號,再創建行事曆。

這樣每月會自動建立一段 5 天的事件區間,不需每月重新設定或個別新增,維護成本低、實用性高。


💻 範例程式碼:從 Google Sheet 自動建立年度與每月重複事件

可以直接使用的 Google Apps Script 範例放在文章最後。

☑️ 試算表欄位格式

請依照下列欄位順序設定試算表內容,每列代表一個事件:

頻率 事件名稱 內容說明 開始日期 結束日期 開始時間 結束時間 重複類型
檢查年度開銷 房屋、所得稅、保費 4月1日 5月31日 15:30 16:30 平日
報名龍舟 關切活動舉辦:KSD高雄市政府運動發展局 3月20日 4月10日 15:30 16:30 平日
🗿預約阿里山日出音樂會 12月1日 12月15日 15:30 16:30 平日
查臺東熱氣球 5月1日 5月15日 15:30 16:30 平日
熱氣球預約停車 https://www.luyepark.com.tw/ 6月15日 6月30日 15:30 16:30 平日
管理費 固定每月 1~5 號 6月1日 6月5日 15:30 16:30 每日

📌 備註:

  • 「頻率」僅支援 ,其他將略過。
  • 「重複類型」建議使用 平日每日,分別對應到 weekday-only 或 daily rule。
  • 日期欄位請確保為日期格式(非文字),時間欄位也建議使用時間格式。

⏰ 搭配觸發器:讓腳本自動執行

為了讓這段腳本能自動運作,請使用 時間驅動觸發器(Time-driven trigger)

  1. 打開 Apps Script 編輯器(從試算表點選「擴充功能 > Apps Script」)
  2. 點選左側時鐘圖示「觸發條件(Triggers)」
  3. 新增觸發器 → 選擇 createMonthlyEvents 函式
  4. 執行方式選擇「時間驅動」
  5. 建議排程為「每月 1 日執行一次」
  6. 新增觸發器 → 選擇 triggerAnnualEvents 函式
  7. 執行方式選擇「時間驅動」
  8. 建議排程為「每日執行一次」

這樣您每個月一打開行事曆,就會看到自動建立好的新事件。


🧰 適合詢問 GPT 的指令(Prompt 建議)

「請幫我寫一段 Apps Script,自動讀取 Google Sheet 中頻率為『年』或『月』的事件,並在 Google Calendar 建立每年/每月的平日提醒。」

也可以這樣加上條件:

「每月事件需自動更新日期區間為當月1~5號,再逐天建立平日事件,不使用重複事件功能。」


🧩 小結:用腳本,把『想記得的事』留在行事曆

透過這個工具,我現在只要維護一張表格,所有提醒就能自動更新到 Google Calendar。

更棒的是,我可以清楚掌握每個事件的重複邏輯與建立狀態,不再怕重複、不再怕漏掉。
這是一種「把記憶交給程式」的感覺。


// === 每月任務入口 ===
function createMonthlyEvents() {
  updateMonthlyExpensesDates();
  processEventsByFrequency("月");
}

function updateMonthlyExpensesDates() {
  const sheetId = "YOUR_SHEET_ID";
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName("事件行事曆");
  const data = sheet.getDataRange().getValues();
  const now = new Date();
  const year = now.getFullYear();
  const month = now.getMonth();
  const startDate = new Date(year, month, 1);
  const endDate = new Date(year, month, 5);

  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    const frequency = row[0];
    if (frequency === "月") {
      sheet.getRange(i + 1, 4).setValue(startDate);
      sheet.getRange(i + 1, 5).setValue(endDate);
    }
  }
}

// === 年度任務入口 ===
function triggerAnnualEvents() {
  const today = new Date();
  if (today.getMonth() === 0 && today.getDate() === 1) {
    createAnnualEvents();
  } else {
    Logger.log("❌ 今天不是 1月1日,略過年度事件建立");
  }
}

function createAnnualEvents() {
  processEventsByFrequency("年");
}

function processEventsByFrequency(targetFrequency) {
  const sheetId = "YOUR_sheetId";
  const sourceSheetName = "事件行事曆";
  const logSheetName = "執行紀錄";
  const calendar = CalendarApp.getDefaultCalendar();
  const data = getSheetData(sheetId, sourceSheetName);
  const results = [];

  const LINE_CHANNEL_ACCESS_TOKEN = 'YOUR_LINE_CHANNEL_ACCESS_TOKEN';
  const USER_ID = 'YOUR_USER_ID';

  for (let i = 1; i < data.length; i++) {
    const [frequency, title, description, startText, endText, startTimeText, endTimeText, repeatType] = data[i];
    const rowIndex = i + 1;

    if (frequency !== targetFrequency) {
      const msg = `🔸 跳過第 ${rowIndex} 列:「${title}」→ 頻率不符(${frequency})`;
      results.push(msg);
      Logger.log(msg);
      continue;
    }

    try {
      const startDate = parseDateTime(startText, startTimeText);
      const endDate = parseDateTime(startText, endTimeText);
      const untilDate = parseDateTime(endText, "23:59");

      if (checkIfEventExists(calendar, title, startDate, endDate)) {
        const msg = `⏭️ 已存在 第 ${rowIndex} 列:「${title}」\n時間:${formatDateTime(startDate)} ~ ${formatTime(endDate)}`;
        results.push(msg);
        Logger.log(msg);
        continue;
      }

      let recurrence;

      if (frequency === "年") {
        recurrence = buildWeekdayRecurrence(untilDate);
      } else if (frequency === "月") {
        const diffDays = Math.floor((untilDate - startDate) / (1000 * 60 * 60 * 24)) + 1;
        recurrence = CalendarApp.newRecurrence()
          .addDailyRule()
          .times(diffDays);
      } else {
        const msg = `⚠️ 不支援的頻率類型:「${frequency}」於第 ${rowIndex} 列`;
        results.push(msg);
        Logger.log(msg);
        continue;
      }

      calendar.createEventSeries(title, startDate, endDate, recurrence, {
        description: description
      });

      const msg = `✅ 已建立 第 ${rowIndex} 列:「${title}」\n時間:${formatDateTime(startDate)} ~ ${formatTime(endDate)}\n重複:${frequency}`;
      results.push(msg);
      Logger.log(msg);
      Utilities.sleep(200);

    } catch (e) {
      const msg = `❌ 錯誤 第 ${rowIndex} 列:「${title}」→ ${e.message}`;
      results.push(msg);
      Logger.log(msg);
    }
  }

  const summary = `📅 ${targetFrequency} 行事曆建立結果(共 ${results.length} 筆)\n\n${results.join("\n\n")}`;
  sendLineMessage(summary, LINE_CHANNEL_ACCESS_TOKEN, USER_ID);
  writeExecutionLog(sheetId, logSheetName, results);
}

function getSheetData(sheetId, sheetName) {
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(sheetName);
  if (!sheet) throw new Error("🚫 找不到工作表:「" + sheetName + "」");
  return sheet.getDataRange().getValues();
}

function parseDateTime(baseDate, timeInput) {
  if (!(baseDate instanceof Date)) throw new Error("🚫 日期欄位應為 Date 物件:" + baseDate);
  let hour = 0, minute = 0;
  if (typeof timeInput === 'string') {
    [hour, minute] = timeInput.split(":").map(n => parseInt(n, 10));
  } else if (timeInput instanceof Date) {
    hour = timeInput.getHours();
    minute = timeInput.getMinutes();
  } else {
    throw new Error("❌ 時間格式錯誤:" + timeInput);
  }
  const newDate = new Date(baseDate);
  newDate.setHours(hour, minute, 0, 0);
  return newDate;
}

function checkIfEventExists(calendar, title, startTime, endTime) {
  const events = calendar.getEvents(startTime, endTime);
  return events.some(e =>
    e.isRecurringEvent() &&
    e.getTitle() === title &&
    e.getStartTime().getHours() === startTime.getHours()
  );
}

function buildWeekdayRecurrence(untilDate) {
  return CalendarApp.newRecurrence()
    .addWeeklyRule()
    .onlyOnWeekdays([
      CalendarApp.Weekday.MONDAY,
      CalendarApp.Weekday.TUESDAY,
      CalendarApp.Weekday.WEDNESDAY,
      CalendarApp.Weekday.THURSDAY,
      CalendarApp.Weekday.FRIDAY
    ])
    .until(untilDate);
}

function writeExecutionLog(sheetId, logSheetName, results) {
  const sheet = SpreadsheetApp.openById(sheetId).getSheetByName(logSheetName);
  if (!sheet) throw new Error("🚫 找不到『" + logSheetName + "』工作表");
  const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd HH:mm:ss");
  const rows = results.map(msg => [timestamp, msg]);
  sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
}

function sendLineMessage(message, token, userId) {
  const url = "https://api.line.me/v2/bot/message/push";
  const payload = {
    to: userId,
    messages: [{ type: "text", text: message }]
  };
  const options = {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify(payload),
    headers: { Authorization: "Bearer " + token }
  };
  UrlFetchApp.fetch(url, options);
}

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
bigsmile
iT邦新手 4 級 ‧ 2025-06-24 12:03:19

很好的分享,可以來試作看看

我要留言

立即登入留言